CSS Specificity를 낮게 유지하는 방법

CSS Specificity를 낮게 유지하는 방법

한눈에 보기

Specificity는 CSS 우선순위의 전부가 아니라 cascade가 같은 origin과 layer의 후보를 비교할 때 사용하는 한 단계다. 선택자를 강하게 만들어 문제를 이기려 하기보다 layer로 스타일의 역할을 정하고, 컴포넌트 선택자는 낮게 유지하며, 상태는 클래스나 data-* 속성으로 명시하는 편이 오래 유지된다.

CSS 파일이 작을 때는 원하는 규칙을 아래쪽에 한 번 더 쓰면 문제가 해결된다. 프로젝트가 커지면 어느 순간부터 같은 방법이 통하지 않는다.

#app .page .checkout .summary button.primary {
  background: #2563eb;
}

.danger-button {
  background: #dc2626;
}

마크업에 .danger-button을 추가해도 파란색이 남는다. 개발자는 급한 마음에 !important를 붙인다.

.danger-button {
  background: #dc2626 !important;
}

그다음 hover 상태를 바꾸려면 또 !important가 필요하고, 다크 모드나 disabled 상태까지 같은 경쟁에 들어온다. 이 문제의 본질은 개발자가 specificity 숫자를 외우지 못해서가 아니다. 서로 다른 역할의 스타일이 같은 우선순위 공간에서 힘으로 경쟁하도록 설계되었기 때문이다.

목차

Specificity보다 cascade가 먼저다

브라우저는 같은 속성에 적용될 수 있는 선언을 발견했다고 바로 specificity를 비교하지 않는다. 단순화하면 다음 순서로 승자를 정한다.

flowchart LR
    R["현재 조건에 맞는 규칙"] --> O["origin과 importance"]
    O --> L["cascade layer"]
    L --> S["specificity"]
    S --> P["scope proximity"]
    P --> A["작성 순서"]
    A --> W["최종 선언"]

예를 들어 서로 다른 layer에 있는 두 규칙은 selector 점수를 비교하기 전에 layer 우선순위에서 결정될 수 있다.

@layer reset, components, utilities;

@layer components {
  #checkout .button {
    background: royalblue;
  }
}

@layer utilities {
  .bg-red {
    background: crimson;
  }
}

normal 선언에서는 뒤에 선언된 utilities layer가 앞의 components layer보다 우선한다. .bg-red의 specificity가 훨씬 낮아도 layer 단계에서 이미 승자가 된다.

이 사실은 중요한 설계 방향을 알려 준다. 우선순위를 조절하기 위해 선택자를 계속 강하게 만들 필요가 없다. 스타일의 역할을 layer로 분리하면 specificity는 각 layer 내부의 작은 충돌만 해결하게 할 수 있다.

작성 순서는 마지막 비교 기준이다

“CSS는 아래에 쓴 것이 이긴다”는 설명은 같은 origin, importance, layer, specificity, scope proximity를 가진 선언끼리만 맞는다.

Specificity 점수를 읽는 방법

Specificity는 보통 세 칸으로 설명한다.

포함되는 선택자
ID ID 선택자 #checkout
CLASS 클래스, 속성, pseudo-class .button, [disabled], :hover
TYPE 태그, pseudo-element button, ::before

인라인 스타일은 일반 author stylesheet의 normal 선언보다 별도의 높은 우선순위를 가진다. 실무 계산에서는 style 속성을 일반 selector 점수와 같은 방식으로 억지로 섞기보다 별도 우선순위로 이해하는 편이 정확하다.

button                 { } 
.button                { }
.toolbar .button:hover { }
#checkout .button      { }

각각의 개념적인 점수는 다음과 같다.

button                  0-0-1
.button                 0-1-0
.toolbar .button:hover  0-3-0
#checkout .button       1-1-0

점수는 십진수 하나로 더하지 않고 왼쪽 칸부터 비교한다. ID 하나는 클래스가 여러 개 붙은 선택자보다 앞선다.

몇 가지 pseudo-class에는 별도 규칙이 있다.

:is(#dialog, .popover) .close { }
:where(#dialog, .popover) .close { }

첫 번째 규칙은 :is() 인수의 #dialog 때문에 높은 가중치를 가질 수 있다. 두 번째 규칙은 구조 조건을 표현하지만 :where() 부분은 점수에 포함되지 않는다.

점수 계산은 진단 도구다

숫자를 정확히 계산할 수 있다고 구조가 좋아지는 것은 아니다. 자주 점수를 계산해야 한다면 역할이 다른 스타일이 같은 요소를 과도하게 제어하고 있지 않은지 먼저 본다.

강한 선택자가 유지보수를 어렵게 하는 이유

깊은 후손 선택자는 DOM 구조와 스타일을 강하게 결합한다.

.checkout-page .summary-panel ul li button {
  min-height: 44px;
}

이 규칙에는 두 가지 비용이 있다.

첫째, 마크업에서 <ul><div>로 바꾸면 스타일이 사라진다. 디자인 역할은 “결제 버튼”인데 selector는 우연한 HTML 경로를 표현한다.

둘째, 다른 화면에서 버튼을 재사용해도 같은 스타일을 얻을 수 없다. 결국 비슷한 규칙을 복사하거나 더 넓은 selector를 추가하게 된다.

역할에 이름을 붙이면 의도가 선명해진다.

.checkout-action {
  min-height: 44px;
}

ID selector도 같은 문제를 만든다.

#payment-submit {
  background: var(--color-action);
}

ID는 문서 내 식별과 anchor 연결에는 유용하지만 스타일 훅으로 쓰면 override 비용이 커진다. 일반적인 컴포넌트 스타일에는 클래스를 우선한다.

강한 selector가 쌓일 때 나타나는 신호는 다음과 같다.

단일 클래스와 명시적인 상태를 사용한다

컴포넌트의 기본 모양은 가능하면 한 개의 낮은 specificity 클래스로 표현한다.

.button {
  display: inline-flex;
  align-items: center;
  min-height: 2.75rem;
  padding-inline: 1rem;
  border-radius: 0.5rem;
}

variant는 별도 클래스나 속성으로 표현한다.

.button--primary {
  color: white;
  background: var(--color-action);
}

.button--danger {
  color: white;
  background: var(--color-danger);
}

.button[data-loading="true"] {
  cursor: progress;
  opacity: 0.72;
}

.button:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

React 예시에서는 상태를 마크업에 명시적으로 투영할 수 있다.

type SaveButtonProps = {
  loading: boolean;
  onSave: () => void;
};

export function SaveButton({
  loading,
  onSave,
}: SaveButtonProps) {
  return (
    <button
      type="button"
      className="button button--primary"
      data-loading={loading}
      disabled={loading}
      onClick={onSave}
    >
      {loading ? "저장 중" : "저장"}
    </button>
  );
}

data-loading은 시각적 selector를 제공하고 disabled는 실제 브라우저 동작과 접근성 상태를 제공한다. 스타일만을 위해 ARIA 속성을 오용하지 않는다. ARIA는 보조 기술에 전달할 의미가 있을 때 실제 상태와 일치하도록 쓴다.

부모 상태가 자식에 영향을 주는 경우에도 범위를 짧게 유지한다.

.card[data-selected="true"] {
  border-color: var(--color-selected-border);
}

.card[data-selected="true"] .card__title {
  color: var(--color-selected-text);
}

DOM 전체 경로가 아니라 컴포넌트 root와 내부 element라는 안정적인 관계만 표현한다.

:where()로 구조의 가중치를 제거한다

reset이나 기본 컴포넌트 규칙은 쉽게 덮어쓸 수 있어야 한다. :where()는 selector 조건을 유지하면서 specificity를 0으로 만든다.

:where(ul, ol)[class] {
  margin: 0;
  padding: 0;
  list-style: none;
}

여기서 :where(ul, ol) 부분은 가중치를 올리지 않는다. [class]만 CLASS 칸에 반영된다.

컴포넌트 root의 기본 스타일 전체를 낮출 수도 있다.

:where(.card) {
  padding: 1rem;
  border: 1px solid var(--color-border);
  border-radius: 0.75rem;
  background: var(--color-surface);
}

.card--featured {
  border-color: var(--color-accent);
}

.card--featured가 뒤에 있거나 적절한 layer에 있다면 기본 규칙을 자연스럽게 덮을 수 있다.

전역 테마 조건도 가중치 폭발 없이 표현할 수 있다.

:where([data-theme="dark"]) .card {
  --color-surface: #18181b;
  --color-border: #3f3f46;
}

다만 :where()를 모든 selector에 기계적으로 붙이지 않는다. 어떤 규칙이 의도치 않게 너무 쉽게 덮이면 디버깅이 어려울 수 있다. reset, 기본값, 소비자가 확장하도록 만든 라이브러리 스타일처럼 낮은 우선순위가 계약인 곳에 사용한다.

Cascade Layer로 우선순위를 설계한다

프로젝트가 커지면 파일 import 순서 대신 layer 순서를 명시하는 것이 유용하다.

@layer reset, tokens, base, components, utilities, overrides;

normal 선언은 뒤 layer가 앞 layer보다 우선한다.

layer 책임
reset 브라우저 차이 정리 box sizing, 기본 margin
tokens 사용자 정의 속성 색상, 간격, typography
base 태그 기본값 body, heading, link
components 재사용 UI button, card, dialog
utilities 작은 단일 목적 override display, spacing
overrides 제한된 통합 예외 레거시 화면 임시 수정
@layer components {
  .notice {
    padding: 1rem;
    color: var(--notice-text);
    background: var(--notice-bg);
  }
}

@layer utilities {
  .is-hidden {
    display: none;
  }
}

.notice.is-hidden 같은 강한 selector 없이 utility가 이길 수 있다.

layer 순서는 각 파일이 우연히 import되는 시점에 맡기지 말고 진입점에서 먼저 선언한다.

@layer reset, vendor, base, components, utilities;

@import url("./reset.css") layer(reset);
@import url("./vendor.css") layer(vendor);
@import url("./base.css") layer(base);
@import url("./components.css") layer(components);
@import url("./utilities.css") layer(utilities);

명시적으로 layer에 넣지 않은 normal author 스타일은 layered normal 스타일보다 우선한다. 일부 파일만 layer로 옮기면 기존 unlayered 규칙이 계속 이길 수 있다는 점을 주의한다.

!important에서는 layer 순서가 반대다

important 선언은 먼저 선언된 layer가 나중 layer보다 우선한다. 이는 브라우저·사용자 중요 스타일을 보호하는 cascade의 방향과 연결된다. layer 안에서도 !important를 일상적인 override 수단으로 쓰지 않아야 혼란을 피할 수 있다.

서드파티 CSS를 안전하게 덮어쓰기

서드파티 위젯이 높은 specificity를 사용하면 애플리케이션 CSS에 더 강한 selector를 추가하기 쉽다.

.app .checkout #provider-widget button.action {
  font-family: inherit !important;
}

가능하다면 서드파티 CSS를 낮은 layer로 가져온다.

@layer vendor, components, utilities;

@import url("provider-widget.css") layer(vendor);

@layer components {
  .payment-action {
    font: inherit;
    border-radius: 0.5rem;
  }
}

application component layer가 vendor layer보다 뒤에 있으므로 낮은 specificity로 override할 수 있다.

서드파티 코드가 inline style을 쓰거나 중요한 선언을 다수 사용하면 layer만으로 충분하지 않을 수 있다. 이 경우 다음 순서로 판단한다.

  1. 라이브러리가 theme API나 CSS custom property를 제공하는지 확인한다.
  2. 공식 className·slot customization을 사용한다.
  3. wrapper 범위 안에 통합 override를 격리한다.
  4. 불가피한 !important에는 대상과 제거 조건을 주석이나 이슈로 기록한다.

라이브러리 내부 DOM에 의존하는 긴 selector는 업데이트 때 쉽게 깨진다. public customization API가 없다면 해당 위험 자체를 의존성 선택 기준에 포함해야 한다.

CSS Modules와 Tailwind가 해결하는 범위

CSS Modules는 class name 충돌을 줄인다.

import styles from "./button.module.css";

export function Button() {
  return <button className={styles.root}>저장</button>;
}

생성된 고유 class는 다른 .root와 이름이 겹치는 문제를 막지만 cascade를 없애지는 않는다. 같은 요소에 여러 module class와 전역 utility가 적용되면 layer, specificity, 작성 순서가 여전히 작동한다.

Tailwind는 작은 utility를 조합해 깊은 selector 작성을 줄이는 데 도움이 된다.

<button className="rounded-lg bg-blue-600 px-4 py-2 text-white">
  저장
</button>

하지만 조건부 class가 충돌하거나 임의값이 늘고, 외부 CSS와 섞이면 우선순위 문제가 다시 생길 수 있다. class 병합 도구는 알려진 utility 충돌을 정리할 뿐 모든 cascade 문제를 해결하지 않는다.

<Button className="bg-red-600" />

컴포넌트 내부 기본 class와 소비자 class 중 무엇이 이겨야 하는지 variant API와 병합 순서로 명확히 정한다. 이는 05. 재사용 가능한 컴포넌트 패턴 clsx twMerge cvaTailwind에서 디자인 토큰을 유지하는 방법의 주제와 이어진다.

Shadow DOM은 더 강한 캡슐화를 제공하지만 테마 전달과 접근성, 스타일 customization 계약을 별도로 설계해야 한다. 범위화 도구를 선택해도 우선순위 정책은 필요하다.

!important가 필요한 경우와 격리 방법

!important는 무조건 금지할 문법은 아니다. 사용자 접근성을 보호하거나 외부 inline 스타일을 제한적으로 이겨야 하는 경우가 있다.

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    scroll-behavior: auto !important;
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
  }
}

이 예시는 애니메이션을 정의한 위치와 specificity에 상관없이 사용자 설정을 우선하려는 명확한 목적이 있다.

좋은 사용 기준은 다음과 같다.

반대로 feature 개발 중 “내 규칙이 안 먹어서” 붙이는 것은 cascade 구조 문제를 미룬다.

.checkout-submit {
  color: white !important;
}

이 경우 DevTools의 Computed 패널에서 승리한 선언의 origin, layer, specificity를 먼저 확인한다. 원인을 알지 못한 채 important를 추가하면 다음 상태가 더 어려워진다.

기존 코드를 단계적으로 낮추는 방법

레거시 CSS 전체를 한 번에 바꾸면 시각 회귀 범위가 너무 커진다. 작은 컴포넌트부터 다음 순서로 진행한다.

1. 충돌이 잦은 속성을 찾는다

DevTools와 코드 검색으로 !important, ID selector, 세 단계 이상의 후손 selector를 찾는다.

rg "!important|#[a-zA-Z_-]+|\\.[a-zA-Z_-]+ .*\\.[a-zA-Z_-]+ " src

정규식 결과는 후보일 뿐 자동으로 나쁜 규칙이라 단정하지 않는다.

2. 역할과 상태에 이름을 붙인다

#app .profile form .actions button:last-child {
  background: crimson;
}
.profile-delete-action {
  background: var(--color-danger);
}

DOM 위치가 아니라 역할을 선택자로 만든다.

3. layer 순서를 먼저 선언한다

기존 스타일은 legacy layer, 새 컴포넌트는 그 뒤 layer로 옮겨 점진적으로 우선순위를 관리할 수 있다.

@layer legacy, components, utilities;

단, 기존 unlayered 규칙이 남아 있으면 layered normal 규칙보다 높으므로 마이그레이션 범위를 명확히 한다.

4. 시각 회귀를 상태별로 확인한다

기본 화면만 비교하지 않는다.

5. 예외 예산을 둔다

!important와 ID style selector를 lint 또는 리뷰 항목으로 관리한다. 무조건 빌드를 막기보다 허용 이유와 소유자를 기록하도록 하는 방식도 현실적이다.

리뷰와 검증 체크리스트

선택자

Cascade 구조

회귀 방지

마무리

Specificity를 낮게 유지하는 목적은 짧은 selector 자체가 아니다. 컴포넌트가 어디에 놓였는지와 무관하게 예측 가능한 스타일을 갖고, 소비자가 문서화된 방법으로 확장할 수 있게 만드는 것이다.

이를 위해 먼저 cascade의 판정 순서를 이해해야 한다. layer가 다른 선언은 specificity를 비교하기 전에 승패가 정해진다. 컴포넌트 내부에서는 단일 클래스와 명시적인 상태를 사용하고, reset이나 기본값처럼 쉽게 덮어써야 하는 규칙에는 :where()를 활용한다. vendor와 utility의 우선순위는 selector 길이가 아니라 layer로 표현한다.

좋은 CSS 구조에서는 새로운 요구가 생겼을 때 더 강한 selector를 발명하지 않는다. 어느 역할의 규칙이 우선해야 하는지 구조로 설명하고, 같은 역할 안에서는 낮고 일정한 specificity를 유지한다.

관련 노트

참고 자료